昨天用 AI 輔助學習了 KMP 最佳實踐,今天該來實戰了。
作為系統設計師,我要從一張白紙開始規劃整個專案架構。
還記得第一次看到 Kotlin Multiplatform 的專案結構時,我整個人是懵的:
your-project/
├── shared/
├── androidApp/
├── iosApp/
└── desktopApp/
這看起來很簡單對吧?但當你真正開始寫程式碼時,問題來了:
今天,讓我分享從零開始設計 Grimo 專案架構的完整歷程。
KMP 的核心理念很簡單:Write Once, Run Everywhere(該共享的部分)。
但這不代表所有東西都要共享。關鍵是找到平衡點:
// 適合共享的
class ProjectRepository {
suspend fun getProjects(): List<Project> // 業務邏輯
}
// 不適合共享的
@Composable
fun IOSNavigationBar() { // 平台特定 UI
// iOS 特有的導航列
}
KMP 提供了優雅的方式處理平台差異:
// commonMain - 定義預期
expect fun getPlatformName(): String
expect class DatabaseDriver
// desktopMain - 實際實作
actual fun getPlatformName(): String = "Desktop"
actual class DatabaseDriver : SqlDriver {
// SQLite for Desktop
}
// iosMain - 實際實作
actual fun getPlatformName(): String = "iOS"
actual class DatabaseDriver : SqlDriver {
// SQLite for iOS (不同的路徑處理)
}
作為一人公司,我需要的架構必須易於維護、易於測試、易於擴展,還要關注點分離。
未來的我要能快速理解。沒有 QA 團隊,必須靠自動化測試。要支援未來的多平台需求。一次只專注一件事。
Clean Architecture 完美符合這些需求。
┌─────────────────────────────────────┐
│ Presentation Layer │ <- UI、ViewModel
├─────────────────────────────────────┤
│ Domain Layer │ <- 業務邏輯、Use Cases
├─────────────────────────────────────┤
│ Data Layer │ <- Repository、Data Source
├─────────────────────────────────────┤
│ Infrastructure │ <- Database、Network、檔案系統
└─────────────────────────────────────┘
每一層都有明確的職責:
一開始,我把所有東西都放在 shared:
shared/
├── commonMain/
│ ├── domain/
│ ├── data/
│ ├── presentation/ # 錯誤:UI 不該在這
│ │ ├── screens/
│ │ ├── viewmodels/
│ │ └── theme/
│ └── di/
問題來了。UI 程式碼不該在 shared/commonMain。違反 KMP 最佳實踐。限制了平台特定的 UI 優化。
經過重構後的架構:
grimo/
├── shared/ # 純業務邏輯模組
│ ├── commonMain/
│ │ ├── kotlin/.../
│ │ │ ├── domain/ # 業務實體、Repository 介面
│ │ │ │ ├── model/
│ │ │ │ │ └── Project.kt # 專案實體
│ │ │ │ └── repository/
│ │ │ │ └── ProjectRepository.kt # 介面定義
│ │ │ ├── core/ # 跨平台核心功能
│ │ │ │ ├── error/ # 錯誤處理系統
│ │ │ │ │ ├── AppError.kt
│ │ │ │ │ └── AppResult.kt
│ │ │ │ └── logging/ # 日誌系統
│ │ │ │ └── Logger.kt
│ │ │ ├── data/ # 資料層介面
│ │ │ │ └── database/
│ │ │ │ ├── DatabaseFactory.kt
│ │ │ │ └── MigrationHelper.kt
│ │ │ └── di/ # 依賴注入
│ │ │ └── CommonModule.kt
│ │ └── sqldelight/ # 資料庫 Schema
│ │ └── migrations/
│ │ ├── 1.sqm
│ │ └── 2.sqm
│ └── desktopMain/ # Desktop 平台實作
│ └── kotlin/.../
│ ├── core/error/
│ │ └── PlatformErrorMapping.kt
│ └── data/
│ ├── database/
│ │ └── DesktopDatabaseFactory.kt
│ └── repository/
│ └── ProjectRepositoryImpl.kt
│
├── desktopApp/ # Desktop 應用程式
│ └── src/jvmMain/
│ └── kotlin/.../
│ ├── Main.kt # 應用程式進入點
│ ├── presentation/ # UI 層
│ │ ├── App.kt
│ │ ├── theme/ # 主題系統
│ │ │ ├── Color.kt
│ │ │ ├── Typography.kt
│ │ │ └── Theme.kt
│ │ ├── loading/ # 載入功能
│ │ │ ├── LoadingScreen.kt
│ │ │ ├── LoadingState.kt
│ │ │ └── LoadingViewModel.kt
│ │ └── projectlist/ # 專案列表功能
│ │ ├── ProjectListScreen.kt
│ │ ├── ProjectListViewModel.kt
│ │ ├── ProjectListState.kt
│ │ ├── ProjectListIntent.kt
│ │ └── components/
│ └── di/ # DI 模組
│ └── DesktopAppModule.kt
│
└── docs/ # 專案文件
├── design/ # 設計文件
├── tasks/ # 任務追蹤
└── ironman-2025/ # 鐵人賽文章
每個模組都有明確的邊界:
// 該放的
- 業務實體 (Project, Task, User)
- Repository 介面
- Use Cases (如果有複雜業務邏輯)
- 共用工具 (Logger, ErrorHandler)
// 不該放的
- UI 元件
- ViewModel
- 平台特定實作
// 該放的
- Compose UI 元件
- ViewModel (MVI/MVVM)
- 主題和樣式
- 應用程式進入點
// 不該放的
- 業務邏輯
- 資料庫實作
依賴永遠是單向的:
desktopApp → shared
↓ ↓
不知道 不知道
↑ ↑
shared desktopApp
這確保了:
相比 MVVM,MVI 提供了什麼?
單向資料流,更容易追蹤狀態變化。不可變狀態,減少並發問題。可預測性,每個動作都有明確的結果。
// State - 不可變的狀態
data class ProjectListState(
val projects: List<Project> = emptyList(),
val isLoading: Boolean = false,
val error: AppError? = null
)
// Intent - 使用者意圖
sealed interface ProjectListIntent {
object LoadProjects : ProjectListIntent
data class SelectProject(val id: String) : ProjectListIntent
object AddProject : ProjectListIntent
}
// ViewModel - 處理意圖,更新狀態
class ProjectListViewModel(
private val repository: ProjectRepository
) : ViewModel() {
private val _state = MutableStateFlow(ProjectListState())
val state = _state.asStateFlow()
fun handleIntent(intent: ProjectListIntent) {
when (intent) {
is ProjectListIntent.LoadProjects -> loadProjects()
is ProjectListIntent.SelectProject -> selectProject(intent.id)
is ProjectListIntent.AddProject -> navigateToAddProject()
}
}
private fun loadProjects() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
repository.getAllProjects().fold(
onSuccess = { projects ->
_state.update {
it.copy(
projects = projects,
isLoading = false,
error = null
)
}
},
onFailure = { error ->
_state.update {
it.copy(
isLoading = false,
error = error
)
}
}
)
}
}
}
最初版本只有基本的分層:
專案開始 → 建立 shared/desktopApp
↓
定義 Domain 層 (Project entity)
↓
實作 Repository 介面
↓
加入 SQLDelight
發現需要統一的錯誤處理:
// 引入 Result 模式
sealed class AppResult<out T, out E> {
data class Success<T>(val value: T) : AppResult<T, Nothing>
data class Failure<E>(val error: E) : AppResult<Nothing, E>
}
// 統一錯誤類型
sealed interface AppError {
val message: String
val isRecoverable: Boolean
}
發現 UI 在 shared 的問題,執行重構:
移動所有 UI 相關檔案
↓
shared/presentation → desktopApp/presentation
↓
更新依賴注入
↓
驗證功能正常
共享 UI 開發速度快,程式碼重用率高達 95%,但平台優化有限,維護成本低。
分離 UI 開發速度較慢,程式碼重用率約 60-70%,但能完全控制平台優化,維護成本較高。
為什麼選擇分離?
Grimo 需要桌面優先的體驗。未來可能需要深度系統整合。保持架構彈性。
選擇 SQLDelight 而非 Room。
真正的多平台支援、編譯時 SQL 驗證、類型安全的查詢、優秀的 Migration 支援。
選擇 Koin 而非 Dagger。
簡單易用、KMP 原生支援、不需要註解處理器、對一人團隊友好。
功能需求變化,新功能可能需要新的架構支援。效能瓶頸,發現效能問題時的架構調整。最佳實踐更新,技術生態的演進(如 iOS 穩定版)。團隊成長,從一人到團隊的架構適應。
// 定義介面,而非直接依賴實作
interface ProjectRepository {
suspend fun getProjects(): AppResult<List<Project>, AppError>
}
// 每個模組有明確的公開 API
// internal 修飾符限制模組內部存取
internal class ProjectRepositoryImpl : ProjectRepository
不要試圖一次設計完美架構
↓
從簡單開始,逐步演進
↓
保持重構的勇氣
我使用簡單的文字圖表記錄架構:
Presentation Layer (UI)
↓
ViewModels
↓
Use Cases
↓
Repositories
↓
Data Sources
# 檢查模組依賴
./gradlew :shared:dependencies
# 程式碼複雜度分析
./gradlew detekt
# 架構一致性檢查
./gradlew konsist
經過這段時間的實踐,我最大的體悟是:架構不是雕像,而是生物。
好的架構應該解決實際問題,而非炫技。適應變化,而非僵化。易於理解,而非過度設計。支援成長,而非限制發展。
作為一人公司的系統設計師,我沒有架構評審委員會,沒有技術總監把關。
但這反而讓我更專注於實用性。
每個架構決策都要問自己:這真的解決了問題嗎?三個月後的我能理解嗎?這會讓開發更快還是更慢?
記住,最好的架構是演進出來的,不是設計出來的。
「架構不是為了炫技,而是為了讓未來的自己不想打現在的自己。」
關於作者:Sam,一人公司創辦人。正在打造 Grimo,一個智能任務管理和分配平台。
專案連結:GitHub - grimostudio